Перейти к основному содержимому

5.05. Лямбды, делегаты и отложенная инициализация

Разработчику Архитектору

Лямбды, делегаты и отложенная инициализация

Замыкания — захват контекста и его последствия

Одной из самых мощных возможностей лямбда-выражений и анонимных методов является замыкание — механизм, при котором внутренняя функция получает доступ к переменным из внешней области видимости, даже если эта область уже завершила своё выполнение. В C# замыкание реализуется через компилятор: он автоматически создаёт вспомогательный класс (часто называемый «классом замыкания»), в который переносятся все захваченные переменные. Лямбда-выражение становится методом этого класса, а ссылка на экземпляр класса сохраняется в делегате.

Это позволяет писать выразительный код, где поведение параметризуется не только аргументами, но и окружением:

int threshold = 100;
Func<int, bool> isHigh = value => value > threshold;

Здесь threshold — это не константа, а обычная локальная переменная. Если её значение изменится до вызова isHigh, новое значение будет использовано. Это демонстрирует, что замыкание захватывает не моментальный снимок значения, а саму переменную — её текущее состояние в памяти.

Однако такое поведение может привести к неожиданным эффектам, особенно в циклах. Рассмотрим пример:

var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i));
}
foreach (var action in actions)
{
action(); // Выведет: 3, 3, 3
}

Все лямбды захватывают одну и ту же переменную i. К моменту их выполнения цикл завершён, и i равен 3. Чтобы избежать этого, нужно создать локальную копию переменной внутри тела цикла:

for (int i = 0; i < 3; i++)
{
int local = i;
actions.Add(() => Console.WriteLine(local));
}

Теперь каждая лямбда захватывает свою собственную переменную local, и вывод будет корректным: 0, 1, 2.

Замыкания также влияют на время жизни объектов. Обычно локальные переменные уничтожаются после выхода из метода. Но если они захвачены замыканием, их жизненный цикл продлевается до тех пор, пока существует хотя бы одна ссылка на лямбду, использующую эти переменные. Это может привести к утечкам памяти, если лямбда сохраняется дольше, чем предполагалось, особенно в долгоживущих объектах, таких как события или кэши.

Практические применения

Делегаты, лямбды и отложенная инициализация находят применение почти в каждом аспекте современной разработки на C#.

LINQ — один из самых ярких примеров. Методы вроде Where, Select, OrderBy принимают делегаты в виде лямбд:

var adults = people.Where(p => p.Age >= 18);

Здесь p => p.Age >= 18 — это лямбда, передаваемая как Func<Person, bool>. Без лямбд такой код был бы многословным и менее читаемым.

Обработка событий — классический сценарий для анонимных методов и лямбд. Подписка на событие часто требует одноразовой логики, которую нецелесообразно выносить в отдельный именованный метод.

Фабрики и стратегии — делегаты позволяют инкапсулировать логику создания объектов. Например, вместо жёсткой зависимости от конкретного конструктора можно передать Func<IService>:

public class ServiceHost
{
private readonly Func<IService> _serviceFactory;

public ServiceHost(Func<IService> serviceFactory)
{
_serviceFactory = serviceFactory;
}

public void Start() => _serviceFactory().Run();
}

Это упрощает тестирование и поддержку, так как фабрика может быть легко заменена.

Отложенная загрузка ресурсов — через Lazy<T>. Особенно полезно при работе с тяжёлыми зависимостями: базами данных, внешними API, большими файлами. Объект создаётся только тогда, когда он действительно нужен, что ускоряет запуск приложения и снижает потребление памяти.

Асинхронное программирование — лямбды часто используются в сочетании с async/await, например, в обработчиках задач или при конфигурации цепочек вызовов:

Task.Run(() => ProcessData());

Хотя здесь используется анонимный метод, его можно легко заменить на лямбду, если логика проста.

Распространённые ошибки и лучшие практики

  1. Избегайте захвата изменяемых переменных в циклах. Как показано выше, это ведёт к неочевидному поведению. Всегда создавайте локальную копию.

  2. Не сохраняйте лямбды дольше, чем необходимо. Они могут удерживать ссылки на большие объекты, мешая сборке мусора. Особенно осторожно следует обращаться с подписками на события: забытая отписка через лямбду — частая причина утечек памяти.

  3. Используйте Lazy<T> с осторожностью в многопоточной среде. Хотя он потокобезопасен по умолчанию, это достигается за счёт синхронизации, которая может стать узким местом. Если инициализация гарантированно происходит в одном потоке, можно использовать LazyThreadSafetyMode.None для повышения производительности.

  4. Предпочитайте лямбды анонимным методам, если тело выражения компактно. Они короче, читабельнее и лучше интегрируются с функциональными API.

  5. Не злоупотребляйте сложной логикой внутри лямбд. Если тело занимает больше трёх строк, лучше вынести его в именованный метод. Это улучшает читаемость, тестируемость и возможность повторного использования.

  6. Будьте внимательны к замыканию this. При захвате членов экземпляра (this.field) лямбда сохраняет ссылку на весь объект. Это может привести к тому, что объект не будет собран сборщиком мусора, даже если он больше не используется в основном коде.